#!/usr/bin/python -S
"""
params.py

TODO:

- Rewrite it so that param definitions are dictionaries?  This is more of a
  'algebra' of dictionaries approach.

- Should be separated into various stages.

- Should be a superset of the protocol buffer schema
  (optional/required/repeated, type)

- Composition of params from various modules/classes.  Param namespaces.

- Need to be able to specify parser=ParseDescriptor.  The parser can raise some
  exception like ValidationError too do cause a UsageError.
  Parser can add / at the end of base paths.

- Also, validators after parsing would be good.  Example: Make sure file exists.
"""

__author__ = 'Andy Chu'


import sys

import records

Record = records.Record  # breaking the rules a bit

#
# Exceptions
#

class Error(Exception):
  pass

class InvalidInput(Error):
  pass

class RequiredParameterMissing(InvalidInput):
  pass

class CannotConvertType(InvalidInput):
  pass

class IntegerOutOfRange(InvalidInput):
  pass

class ListTooShort(InvalidInput):
  pass


#
# Definition of parameter types
#

# pos and shortcut are only used command line parameters.  shortcut could be
# used in the future; pos will never be applicatable.

# TODO: 
#   - help=help, and define format
#   - strict=True for webapp parser?  It accepts everything when maybe it should
#   not.  This would be useful for integration.
#   - (in cmdapp) Prohibit passing both by position and by flag?

def _Param(**kwargs):
  # TODO: sort this out with the Record type
  return Record(varname=kwargs['name'].replace('-', '_'), **kwargs)


def RequiredString(name, choices=None, shortcut=None, pos=None, help=''):
  return _Param(
      type='string', name=name, is_required=True, choices=choices,
      shortcut=shortcut, pos=pos, help=help)


# The defaults of None on all the optional parameters can indicate to the
# Resource that the value wasn't passed, and it can do something smarter with
# it, like look up the default value in a map.  Or it can just be hard-coded.

# The 'choices' check is only implemented for strings.

def OptionalString(
    name, default=None, choices=None, shortcut=None, pos=None, help=''):
  return _Param(
      type='string', name=name, is_required=False, default=default,
      choices=choices, shortcut=shortcut, pos=pos, help=help)


def RequiredInteger(
    name, minval=None, maxval=None, shortcut=None, pos=None, help=''):
  return _Param(
      type='int', name=name, is_required=True, choices=None, minval=minval,
      maxval=maxval, shortcut=shortcut, pos=pos, help=help)


def OptionalInteger(
    name, default=None, minval=None, maxval=None, shortcut=None, pos=None,
    help=''):
  return _Param(
      type='int', name=name, is_required=False, default=default, choices=None,
      minval=minval, maxval=maxval, shortcut=shortcut, pos=pos, help=help)


# The default default is False, not None
# No pos= argument now for readability.
def OptionalBoolean(name, default=False, shortcut=None, help=''):
  return _Param(
      type='boolean', name=name, is_required=False, default=default,
      choices=None, shortcut=shortcut, help=help)


def RepeatedStrings(
    name, default=[], choices=None, minlength=0, pos='1+', help=''):
  # Don't allow shortcuts on purpose for now, since the common case is passing
  # it as arguments
  return _Param(
      type='list', name=name, is_required=False, default=default,
      choices=choices, shortcut=None, minlength=minlength, pos=pos, help=help)


#  A placeholder to say that the actor wants to examine undeclared parameters.
UNDECLARED = _Param(
      type='undeclared', name='', is_required=False, choices=None,
      shortcut=None, pos=None)


def CheckRequiredParamsSet(record, param_definition):
  """
  This will go in the body of a Processor or Map.GET/PUT, since errors will need
  to be raised for internal callers using the Python API.
  """
  for param in param_definition:
    if param.is_required and param.varname not in record:
      raise RequiredParameterMissing(
            'Parameter %r is required' % param.name)


def DefaultsRecord(param_definition):
  """Returns a record of the defaults.

  Required parameters have no defaults.
  """
  d = records.Record()
  for param in param_definition:
    if hasattr(param, 'default'):
      d[param.varname] = param.default
  return d


def ConvertTypes(raw_params, param_definition):
  """
  For each parameter in the parameter definition, looks in the raw params
  dictionary, and converts its type.

  Args:
    raw_params: Dictionary of parameter names to values.  Values could be file
        objects as well, as returned by cgi.FieldStorage().
    param_definition: List of Record instances that declare the parameter.

  Returns:
    A dictionary where the values are Python objects (rather than simply
    strings).

  Raises:
    CannotConvertType: If the value cannot be converted to the required Python
      type.
  """
  undeclared_params = dict(raw_params)
  include_undeclared = False

  typed_values = {}

  for param in param_definition:
    if param.type == 'undeclared':
      include_undeclared = True

    try:
      value = raw_params[param.varname]

    except KeyError:
      continue  # This will be dealt with elsewhere

    else:
      if param.type == 'string':
        pass  # No conversion necessary

      elif param.type == 'int':
        try:
          value = int(value)
        except ValueError:
          raise CannotConvertType(
              '%r = %r cannot be converted to an integer' % (param.name, value))

      elif param.type == 'boolean':
        if type(value) is str:  # Could be a bool already
          value = value.lower()
          if value == 'true':
            value = True
          elif value == 'false':
            value = False
          else:
            raise CannotConvertType(
                '%r = %r cannot be converted to a boolean' %
                (param.name, value))

      elif param.type == 'list':
        if type(value) is str:  # Could be a list already
          # 6/2011: Got rid of the 'parsing' module
          raise NotImplementedError
          #value = parsing.TokenizeString(value)

      else:
        raise Error('Invalid parameter type %r' % param.type)

    # Otherwise, leave the type alone.  It could be a file object as well as a
    # string.
    typed_values[param.varname] = value
    del undeclared_params[param.varname]

  if include_undeclared:
    for k, v in undeclared_params.iteritems():
      # NOTE: The undeclared parameters are just strings; they're not really
      # typed.
      typed_values['undeclared.' + k] = v

  return typed_values


# TODO: Should I have validators as something separate?
#   That is, an integer might have a range validator.
#   A string might have a *choice* validator.
#   Or sometimes this is better done on the client side.
#   Custom types might define their own validators, e.g. project name.

def ValidateParamValues(params, param_definition):
  """Validate params against param_definition.

  Raises:
    InvalidInput
    IntegerOutOfRange
  """
  for param in param_definition:
    try:
      value = params[param.varname]

    except KeyError:
      continue  # This will be dealt with elsewhere

    else:
      if param.type == 'int':
        # Note that the check is strictly less than and strictly greater than
        if param.maxval is not None and value > param.maxval:
          raise IntegerOutOfRange(
              '%r = %r is too large' % (param.name, value))
        if param.minval is not None and value < param.minval:
          raise IntegerOutOfRange(
              '%r = %r is too small' % (param.name, value))

      elif param.type == 'list':
        if hasattr(param, 'minlength') and len(value) < param.minlength:
          # TODO: prefix errors with the processor name
          raise ListTooShort('%r = %r is too short (minimum %d)' %
              (param.name, value, param.minlength))

      if param.choices and value not in param.choices:
        raise InvalidInput(
            '%r is not a valid value for %r' % (value, param.name))


# Possible paths through params
#
# 1. Constructor 
#   d. gets keyword arguments (**options)
#   e. Check for any undeclared params (TODO) -- This is equivalent to passinga
#      keyword argument that doesn't exist in Python.
#   f. fill in defaults for missing ones
#   g. ValidateParamValues
#   h. Check that required parameters are set
#
# 2. From Argv in a config file
#    Or From Argv in the command line (need to add help)
#
# 3. From query params
#
# In cases 2 and 3. I need to:
#
#   a. Parse argv or query string to raw params {'foo-bar': '1'}
#   b. Convert types: {'foo-bar': 1}  
#   c. Convert to keyword arguments: {'foo_bar': 1} -- this is suitable for
#      passing as **options
#   d - h.  Same as above

def MakeRecordFromKeywordArgs(kwargs, param_definition):
  """
  Args:
    kwargs: Dict with values keys like 'foo_bar' already, not 'foo-bar', and the
        values are typed (False and not "false")
  """
  # TODO: Keyword arguments are already typed, so I don't need this.  But I do
  # need to save undeclared arguments, so actually that should be moved out of
  # ConvertTypes (argh).
  nice_params = ConvertTypes(kwargs, param_definition)
  # Raises exceptions
  ValidateParamValues(nice_params, param_definition)
  CheckRequiredParamsSet(nice_params, param_definition)
  final_params = DefaultsRecord(param_definition)
  final_params.update(nice_params)
  return final_params


def ToKeywordArgs(raw_params):
  kwargs = {}
  for key, value in raw_params.iteritems():
    kwargs[key.replace('-', '_')] = value
  return kwargs


def MakeRecord(raw_params, param_definition):
  """
  Args:
    raw_params: Raw dictionary of strings to strings, e.g. {"num-cols": "1"}
  """
  kwargs = ToKeywordArgs(raw_params)
  return MakeRecordFromKeywordArgs(kwargs, param_definition)


_FORM_FRAGMENT = """\
<p>
  <b>${actor-name|html}</b>
</p>
<table>
${rows|raw}
</table>
"""

_FRAGMENT_ROW = """\
  <tr>
    <td>
      ${displayed-param-name|html}
    </td>
    <td>
      ${control|raw}
    </td>
  </tr>"""

_TEXT_CONTROL = """\
  <input type="text" name="${param-name|htmltag}" size="100"/>"""

# This isn't used now.  It would be nicer UI-wise to use a checkbox rather than
# a pair of radio button for booleans, but it doesn't work as well for booleans
# where the default is True.
#
# To consider: In the form parser in webapp.py, I treat the absense of values as
# false.  In essense, there would be no default -- =on means True, missing means
# False.

_CHECKBOX_CONTROL = """\
  <input type="checkbox" name="${param-name|htmltag}" ${checked|raw} />"""

_BOOLEAN_CONTROL = """\
  <input type="radio" name="${param-name|htmltag}" value="true"
         ${true-checked|raw} />True
  <input type="radio" name="${param-name|htmltag}" value="false"
         ${false-checked|raw} />False"""


def MakeFormFragment(actor_name, param_definition, prefix=None):
  """
  Args:
    prefix: To prevent name conflicts when different form fragments are on the
        same form, a name prefix can be passed.  It defaults to the actor name.
  """
  if prefix is None:  # prefix could be '' too
    prefix = actor_name + '.'

  rows = []
  for param in param_definition:
    values = {'param-name': prefix + param.name}
    if param.type == 'boolean':
      if param.default is True:
        values['true-checked'] = 'checked="checked"'
        values['false-checked'] = ''
      else:
        values['true-checked'] = ''
        values['false-checked'] = 'checked="checked"'

      control = template.Expand(_BOOLEAN_CONTROL, values)
    else:
      control = template.Expand(_TEXT_CONTROL, values)

    row = template.Expand(_FRAGMENT_ROW, {
        'displayed-param-name': param.name,
        'control': control
        })
    rows.append(row)

  return template.Expand(_FORM_FRAGMENT, {
      'rows': ''.join(rows),
      'actor-name': actor_name,
      })

